iT邦幫忙

2023 iThome 鐵人賽

0

How to store password

Table users as U {
  username varchar [pk]
  hashed_password varchar [not null]
  full_name varchar [not null]
  email varchar [unique, not null]
  password_changed_at timestamptz [not null, default: '0001-01-01 00:00:00Z']
  created_at timestamptz [not null, default: `now()`]
}

我們在User Table中定義了hashed_password是一個varchar,接下我們將學習如何安全地存儲用戶密碼到Database裡。

**Storing Passwords Securely: Hashing with Bcrypt

  • 在保護用戶數據的過程中,非常重要的一環是確保密碼不以明文形式儲存。在此,我們將探討使用bcrypt雜湊功能來雜湊密碼的方法。

    https://ithelp.ithome.com.tw/upload/images/20231017/20121746dDELNvRLeL.png

  • Never Store Naked Passwords

    • 絕對不要儲存裸露或明文狀態的密碼,以防止安全漏洞。
  • Bcrypt Hashing Function

    • 在將密碼存儲到數據庫之前,利用bcrypt功能進行Hashing
    • 這個功能不僅雜湊密碼,而且還將“Salt”和“Cost”參數集成到Hashing的過程中。
  • Cost Parameter

    • bcrypt雜湊中的一個重要組件,它決定算法將經歷的密鑰Expansion rounds。
    • 調整成本參數會影響雜湊和隨後驗證密碼所需的計算時間,因此它是平衡安全性和性能的關鍵參數。
  • Random Salt Generation

    • 為每次雜湊操作生成隨機的“Salt”。
    • 通過確保相同的輸入密碼產生不同的雜湊輸出,來幫助保護數據庫免受彩虹表攻擊。
  • Final Hash String

    • 最終的雜湊字符串由雜湊值、CostSalt組成。
    • 這個字符串代表著密碼,將儲存在數據庫中。
  • 使用bcrypt來雜湊密碼是一種安全可靠的方法,它直接將諸如加鹽和密鑰擴展輪這樣的安全元素整合到雜湊值中。

  • bcrypt參數的適當配置確保了對常見的密碼破解技術(Rainbow table attack的堅固防禦。

    • Rainbow table attack
      • Rainbow table attack 是一種常見的密碼破解攻擊,利用了雜湊函數的不可逆性。雜湊函數可以將任意長度的字符串轉換為一個固定長度的雜湊值,即使是相同的字符串,雜湊值也不同。
      • Rainbow table attack 的工作原理是先計算出大量常用密碼的雜湊值,並將這些雜湊值和密碼存儲在一個表中,稱為彩虹表。當黑客獲得了一個密碼雜湊值時,可以從彩虹表中查找對應的密碼。
      • Rainbow table attack 的攻擊效率取決於彩虹表的大小和雜湊函數的複雜性。彩虹表越大,攻擊效率越高。雜湊函數越複雜,攻擊效率越低。
      • 為了防止 rainbow table attack,可以使用鹽值來加強密碼哈希算法的安全性。鹽值是一種隨機字符串,可以與密碼一起進行哈希運算。即使是相同的密碼,使用不同的鹽值也會產生不同的雜湊值。這樣,即使黑客獲得了一個密碼雜湊值,也無法從彩虹表中查找對應的密碼。
      • 以下是一些防止 rainbow table attack 的措施:
        • 使用強密碼。強密碼應至少包含 8 個字符,並包含字母、數字和符號的組合。
        • 使用鹽值。鹽值可以與密碼一起進行哈希運算,可以有效地防止 rainbow table attack。
        • 使用高強度的哈希算法。高強度的哈希算法可以有效地防止 rainbow table attack。
  • 在bcrypt雜湊字符串中包含四個主要部分,這些部分共同形成了一個用於數據庫存儲的安全密碼哈希:

    https://ithelp.ithome.com.tw/upload/images/20231017/20121746JMK7GB6MPU.png

    1. Hash Algorithm Identifier (雜湊算法識別碼):
      • 第一部分是雜湊算法的識別碼。
      • “2A” 是bcrypt算法的識別碼。
    2. Cost Parameter (成本參數):
      • 第二部分是Cost
      • 在這個例子中,Cost是10,這意味著會有 2^10 = 1024 rounds of key expansion。
    3. Salt :
      • 第三部分是長度為16 bytes或128 bites的“Salt”。
      • 它使用base64格式進行編碼,這將產生一個由22個字符組成的字符串。
    4. Hash Value (雜湊值):
      • 最後一部分是24 bytes的雜湊值,編碼為31個字符。
  • 使用者登入密碼驗證流程

    https://ithelp.ithome.com.tw/upload/images/20231017/201217469wu8obwaFZ.png

    1. 搜尋資料庫中的雜湊密碼
      • 根據使用者名稱找到儲存在資料庫中的hashed_password
    2. naked_password’s hashing
      • 使用資料庫中該hashed_passwordcostsalt作為參數。
      • 將剛由使用者輸入的naked_password進行bcrypt雜湊,會產生另一個雜湊值。
    3. 雜湊值的比較
      • 比較兩個雜湊值。
      • 如果相同,則證明密碼正確。

Implement functions to hash and compare passwords

  • 我們已經透過SQLC 幫我們建立出CreateUser Function,其中hashed_password為輸入的參數之一:

    type CreateUserParams struct {
        Username       string `json:"username"`
        HashedPassword string `json:"hashed_password"`
        FullName       string `json:"full_name"`
        Email          string `json:"email"`
    }
    
    func (q *Queries) CreateUser(ctx context.Context, arg CreateUserParams) (User, error) {
        row := q.db.QueryRowContext(ctx, createUser,
            arg.Username,
            arg.HashedPassword,
            arg.FullName,
            arg.Email,
        )
        var i User
        err := row.Scan(
            &i.Username,
            &i.HashedPassword,
            &i.FullName,
            &i.Email,
            &i.PasswordChangedAt,
            &i.CreatedAt,
        )
        return i, err
    }
    
  • 此外,在db/sqlc/user_test.go中的createRandomUser()單元測試函數中,我們用了一個簡單的"secret"字串作為hash_password字段的值,但這並不反映該字段應有的正確值:

    func createRandomUser(t *testing.T) User {
        arg := CreateUserParams{
            Username:       util.RandomOwner(),
            HashedPassword: "secret",
            FullName:       util.RandomOwner(),
            Email:          util.RandomEmail(),
        }
    
        ...
    }
    
  • 所以接下來我們將更新它們來使用真正的Hash String

Hash password function

  • 創建一個新文件 password.goutil package 中。

  • 定義一個新函數 HashPassword(),其功能是:

    util/password.go
    
    // HashPassword returns the bcrypt hash of the password
    func HashPassword(password string) (string, error) {
        hashedPassword, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
        if err != nil {
            return "", fmt.Errorf("failed to hash password: %w", err)
        }
        return string(hashedPassword), nil
    }
    
    • Input:一個類型為字符串的密碼。
    • ReturnhashedPasswordstring)或者錯誤訊息(failed to hash password)。
    • 利用 bcrypt.GenerateFromPassword() 函數來計算密碼的bcrypt哈希值。
    • 密碼從string轉換到 []byte 切片。
    • 使用 bcrypt.DefaultCost(值是10)作為哈希的cost參數。
    • %w vs %s
      • 在 Go 語言中,fmt.Errorf 函數用來格式化錯誤訊息。在這個函數中,%w 是一個特殊的格式化指令,它不僅可以將一個錯誤物件格式化為字符串,還可以保留原始的錯誤資訊,使得後續可以使用 errors.Iserrors.As 函數來檢查或提取原始錯誤。
      • 這與 %s 指令有所不同,因為 %s 只是簡單地將錯誤物件格式化為字符串,而不保留任何原始錯誤資訊。所以,使用 %w 可以提供更多的彈性和功能,特別是當你想保留原始錯誤的上下文資訊時。

Compare passwords function

  • 定義另一函數 CheckPassword(),其功能和特點是:

    func CheckPassword(password, hashedPassword string) error {
    	return bcrypt.CompareHashAndPassword([]byte(hashedPassword), []byte(password))
    }
    
    • Input:一個需要檢查的密碼和一個用來比較的hashedPassword
    • return:錯誤訊息(如果有的話)。
    • 利用 bcrypt.CompareHashAndPassword() 函數來比較哈希密碼和原始密碼,這兩者都從字符串轉換到 []byte 切片。

單元測試:HashPassword 和 CheckPassword 函數

util/password_test.go

func TestPassword(t *testing.T) {
	password := RandomString(6)

	hashedPassword, err := HashedPassword(password)
	require.NoError(t, err)
	require.NotEmpty(t, hashedPassword)

	wrongPassword := RandomString(6)
	err = CheckPassword(wrongPassword, hashedPassword)
	require.EqualError(t, err, bcrypt.ErrMismatchedHashAndPassword.Error())
}
  • util package 中創建新文件 password_test.go
  • 定義測試函數 TestPassword(),接收一個 testing.T 對象作為參數。
  • 生成隨機字符串作為密碼,然後獲得其哈希值和比較結果。
  • 也包括了一個錯誤案例測試,提供一個錯誤的密碼,並期望返回 bcrypt.ErrMismatchedHashAndPassword 錯誤。

更新現有代碼以使用 HashPassword 函數

db/sqlc/user_test.go

func createRandomUser(t *testing.T) User {
	hashedPassword, err := util.HashPassword(util.RandomString(6))
	require.NoError(t, err)
	arg := CreateUserParams{
		Username:       util.RandomOwner(),
		HashedPassword: hashedPassword,
		FullName:       util.RandomOwner(),
		Email:          util.RandomEmail(),
	}
  • user_test.go 文件中更新函數,使其使用 HashPassword() 函數。
  • createRandomUser() 函數中,用隨機生成的字符串創建一個新的哈希密碼值。

確保所有哈希密碼都是不同的

util/password_test.go

func TestPassword(t *testing.T) {
	password := RandomString(6)

	hashedPassword1, err := HashPassword(password)
	require.NoError(t, err)
	require.NotEmpty(t, hashedPassword1)

	err = CheckPassword(password, hashedPassword1)
	require.NoError(t, err)

	wrongPassword := RandomString(6)
	err = CheckPassword(wrongPassword, hashedPassword1)
	require.EqualError(t, err, bcrypt.ErrMismatchedHashAndPassword.Error())

	hashedPassword2, err := HashPassword(password)
	require.NoError(t, err)
	require.NotEmpty(t, hashedPassword2)
	require.NotEqual(t, hashedPassword1, hashedPassword2)
}
  • 確保同一密碼哈希兩次時,會產生兩個不同的哈希值。
  • TestPassword() 函數中進行這方面的測試。
  • 使用 require.NotEqual() 來檢查兩次生成的哈希值是否不同。

bcrypt.GenerateFromPassword 函數實現

const (
	majorVersion       = '2'
	minorVersion       = 'a'
	maxSaltSize        = 16
	maxCryptedHashSize = 23
	encodedSaltSize    = 22
	encodedHashSize    = 31
	minHashSize        = 59
)

func GenerateFromPassword(password []byte, cost int) ([]byte, error) {
	if len(password) > 72 {
		return nil, ErrPasswordTooLong
	}
	p, err := newFromPassword(password, cost)
	if err != nil {
		return nil, err
	}
	return p.Hash(), nil
}

func newFromPassword(password []byte, cost int) (*hashed, error) {
	if cost < MinCost {
		cost = DefaultCost
	}
	p := new(hashed)
	p.major = majorVersion
	p.minor = minorVersion

	err := checkCost(cost)
	if err != nil {
		return nil, err
	}
	p.cost = cost

	unencodedSalt := make([]byte, maxSaltSize)
	_, err = io.ReadFull(rand.Reader, unencodedSalt)
	if err != nil {
		return nil, err
	}

	p.salt = base64Encode(unencodedSalt)
	hash, err := bcrypt(password, p.cost, p.salt)
	if err != nil {
		return nil, err
	}
	p.hash = hash
	return p, err
}

GenerateFromPassword 函數

  • 函數定義

    func GenerateFromPassword(password []byte, cost int) ([]byte, error)
    
    

    這個函數接受兩個參數:一個字節切片類型的密碼和一個整型的加密成本。它返回一個字節切片(哈希後的密碼)和一個錯誤物件。

  • 檢查密碼長度

    if len(password) > 72 {
        return nil, ErrPasswordTooLong
    }
    
    

    如果密碼的長度超過 72 字節,則返回一個錯誤,因為 bcrypt 只能處理最多 72 字節的密碼。

  • 呼叫 newFromPassword 函數

    p, err := newFromPassword(password, cost)
    
    

    這行程式碼呼叫 newFromPassword 函數來創建一個新的哈希物件。

  • 返回哈希值

    return p.Hash(), nil
    
    

    如果 newFromPassword 函數成功創建了一個哈希物件,則返回其哈希值和 nil 錯誤。

newFromPassword 函數

  • 函數定義

    func newFromPassword(password []byte, cost int) (*hashed, error)
    
    

    這個函數也接受密碼和加密成本作為參數,並返回一個指向哈希物件的指針和一個錯誤物件。

  • 檢查並設定加密成本

    if cost < MinCost {
        cost = DefaultCost
    }
    
    

    如果提供的加密成本低於最小允許值,則將其設定為默認值。

  • 創建新的哈希物件並設定版本和成本

    p := new(hashed)
    p.major = majorVersion
    p.minor = minorVersion
    p.cost = cost
    
    

    這幾行程式碼創建了一個新的哈希物件並設定了它的版本和加密成本。

  • 生成Random Salt

    unencodedSalt := make([]byte, maxSaltSize)
    _, err = io.ReadFull(rand.Reader, unencodedSalt)
    
    

    這裡創建了一個新的字節切片來存儲鹽,並使用 rand.Reader 來生成隨機鹽。

  • 加密密碼

    p.salt = base64Encode(unencodedSalt)
    hash, err := bcrypt(password, p.cost, p.salt)
    
    

    這兩行程式碼首先將生成的隨機鹽進行 Base64 編碼,然後使用這個鹽和提供的加密成本來生成密碼的 bcrypt 哈希。

  • 設定哈希值並返回哈希物件

    p.hash = hash
    return p, err
    
    

    如果哈希成功生成,則將其設定為哈希物件的哈希屬性並返回哈希物件。如果在此過程中發生任何錯誤,它將返回一個包含錯誤詳情的錯誤物件。

Implement the create user API

接下來,我將使用我們已經編寫的 HashPassword() 函數來實現我們Simple BankcreateUser API。

Step 1: 創建新的檔案

在 api 包中創建一個新的檔案 user.go

Step 2: 設定 createUserRequest 結構

這個 API 會與我們之前實現的創建帳戶 API 非常相似,所以我將從 api/account.go 文件中複製它。然後將這個結構改為 createUserRequest。它包含以下字段:

  • Username: 這是一個必填字段,並且我們不允許它包含任何特殊字符。我將使用由 validator 包提供的 alphanum 標籤,這基本上意味著此字段只應包含 ASCII 字母和數字字符。
  • Password: 這也是一個必填字段。我們通常不希望密碼太短,因為這會很容易被破解。所以我們使用 min 標籤來說明密碼的長度應至少為6個字符。
  • FullName: 用戶的全名,這是一個必填字段,但沒有特定要求。
  • Email: 這是一個非常重要的字段,因為它將是用戶和我們系統之間的主要溝通渠道。我們可以使用 validator 包提供的 email 標籤來確保此字段的值是一個正確的電子郵件地址。
type createUserRequest struct {
    Username string `json:"username" binding:"required,alphanum"`
    Password string `json:"password" binding:"required,min=6"`
    FullName string `json:"full_name" binding:"required"`
    Email    string `json:"email" binding:"required,email"`
}

Step 3: 完成 createUser() 函數

首先,我們使用 ctx.ShouldBindJSON() 函數將輸入參數從上下文綁定到 createUserRequest 對象中。如果有任何參數無效,我們只返回 400 Bad Request 狀態給客戶端。否則,我們將使用它們構建 db.CreateUserParams 對象。

在這裡,我們需要設置4個字段:UsernameHashedPasswordFullnameEmail。首先,我們通過調用 util.HashPassword() 函數並傳遞輸入的 request.Password 值來計算 hashedPassword。如果此函數返回一個非 nil 錯誤,則我們只返回一個 500 Internal Server Error 狀態給客戶端。

func (server *Server) createUser(ctx *gin.Context) {
    var req createUserRequest
    if err := ctx.ShouldBindJSON(&req); err != nil {
        ctx.JSON(http.StatusBadRequest, errorResponse(err))
        return
    }

    hashedPassword, err := util.HashPassword(req.Password)
    if err != nil {
        ctx.JSON(http.StatusInternalServerError, errorResponse(err))
        return
    }

    arg := db.CreateUserParams{
        Username:       req.Username,
        HashedPassword: hashedPassword,
        FullName:       req.FullName,
        Email:          req.Email,
    }

    ...
}

然後,我們用這個輸入參數調用 server.store.CreateUser()。它將返回創建的用戶對象或一個錯誤。

正如在創建帳戶 API 中一樣,如果錯誤不是 nil,則存在一些可能的情景。請記住,在 users 表中,我們有2個唯一的約束:

  1. 用戶名的主鍵
  2. 電子郵件列

我們在這個表中沒有外鍵,所以這裡我們只需要保留 unique_violation 代碼名,以便在具有相同用戶名或電子郵件的用戶已經存在的情況下返回403 Forbidden 狀態。

最後,如果沒有錯誤發生,我們只需返回 200 OK 狀態和創建的用戶給客戶端。

func (server *Server) createUser(ctx *gin.Context) {
    ...

    user, err := server.store.CreateUser(ctx, arg)
    if err != nil {
        if pqErr, ok := err.(*pq.Error); ok {
            switch pqErr.Code.Name() {
            case "unique_violation":
                ctx.JSON(http.StatusForbidden, errorResponse(err))
                return
            }
        }
        ctx.JSON(http.StatusInternalServerError, errorResponse(err))
        return
    }

    ctx.JSON(http.StatusOK, user)
}

Step 4: 在 api/server.go 文件中註冊路由

NewServer() 函數中,我將添加一個新的路由,方法是 POST。它的路徑應該是 /users,它的處理函數是 server.createUser

func NewServer(store db.Store) *Server {
    server := &Server{store: store}
    router := gin.Default()

    if v, ok := binding.Validator.Engine().(*validator.Validate); ok {
        v.RegisterValidation("currency", validCurrency)
    }

    router.POST("/users", server.createUser)

    router.POST("/accounts", server.createAccount)
    router.GET("/accounts/:id", server.getAccount)
    router.GET("/accounts", server.listAccounts)

    router.POST("/transfers", server.createTransfer)

    server.router = router
    return

 server
}

Test the create user API

現在打開終端並運行 make server 來啟動服務器。我將使用 Postman 來測試新的 API。選擇 POST 方法並填寫 URL: http://localhost:8080/users

  • 建立User

    對於請求主體,選擇 raw 並選擇 JSON 格式。我將使用以下 JSON 數據進行測試:

    {
        "username": "quang3",
        "full_name": "Quang Pham",
        "email": "quang3@email.com",
        "password": "secret"
    }
    

    https://ithelp.ithome.com.tw/upload/images/20231017/2012174642XBO9rZqC.png

  • 重複的用戶名稱

    {
        "username": "quang3",
        "full_name": "Quang Pham",
        "email": "quang3@email.com",
        "password": "secret"
    }
    

    https://ithelp.ithome.com.tw/upload/images/20231017/2012174652oQl29Kkm.png

  • 重複的Email

    {
        "username": "quang31",
        "full_name": "Quang Pham",
        "email": "quang3@email.com",
        "password": "secret"
    }
    

    https://ithelp.ithome.com.tw/upload/images/20231017/20121746U2aF31HNZH.png

  • 無效的用戶名稱

    {
        "username": "quang31@",
        "full_name": "Quang Pham",
        "email": "quang3@email.com",
        "password": "secret"
    }
    

    https://ithelp.ithome.com.tw/upload/images/20231017/20121746I21RVcKk1D.png

  • 無效的Email

    {
        "username": "quang31",
        "full_name": "Quang Pham",
        "email": "quang3email.com",
        "password": "secret"
    }
    

    https://ithelp.ithome.com.tw/upload/images/20231017/201217464Gyy31WrAZ.png

  • 過短的密碼

    {
        "username": "quang31",
        "full_name": "Quang Pham",
        "email": "quang31@email.com",
        "password": "ss"
    }
    

    https://ithelp.ithome.com.tw/upload/images/20231017/20121746JRWjW3zhCY.png

API should not expose hashed password

Step 1: 觀察當前問題

你可以注意到在成功建立User後,hashed_password 值也被返回了,這似乎不對,因為客戶端永遠不需要用這個值做任何事情,並且它可能會引起一些安全問題,因為這段敏感信息正在公共網絡中傳輸。

{
    "username": "quang31",
    "full_name": "Quang Pham",
    "email": "quang31@email.com",
    "password": "secret"
}

{
    "username": "quang31",
    "hashed_password": "$2a$10$LEoa0fN0Se.Nbs54UvlVBurRUFivln9kJJvB9IHf06A/t3BV2qWdm",
    "full_name": "Quang Pham",
    "email": "quang31@email.com",
    "password_changed_at": "0001-01-01T00:00:00Z",
    "created_at": "2023-09-20T08:38:55.955744Z"
}

Step 2: 創建一個新的回應結構

為了解決這個問題,我將在 api/user.go 文件中聲明一個新的 createUserResponse 結構。它將包含 db.User 結構的幾乎所有字段,除了應該刪除的 HashedPassword 字段。

type createUserResponse struct {
    Username          string    `json:"username"`
    FullName          string    `json:"full_name"`
    Email             string    `json:"email"`
    PasswordChangedAt time.Time `json:"password_changed_at"`
    CreatedAt         time.Time `json:"created_at"`
}

Step 3: 更新 createUser() 處理函數

然後,在 createUser() 處理函數的末尾,我們構建一個新的 createUserResponse 對象,其中 Usernameuser.UsernameFullNameuser.FullNameEmailuser.EmailPasswordChangedAtuser.PasswordChangedAt,而 CreatedAtuser.CreatedAt

func (server *Server) createUser(ctx *gin.Context) {
    ...

    user, err := server.store.CreateUser(ctx, arg)
    if err != nil {
        if pqErr, ok := err.(*pq.Error); ok {
            switch pqErr.Code.Name() {
            case "unique_violation":
                ctx.JSON(http.StatusForbidden, errorResponse(err))
                return
            }
        }
        ctx.JSON(http.StatusInternalServerError, errorResponse(err))
        return
    }

    rsp := createUserResponse{
        Username:          user.Username,
        FullName:          user.FullName,
        Email:             user.Email,
        PasswordChangedAt: user.PasswordChangedAt,
        CreatedAt:         user.CreatedAt,
    }
    ctx.JSON(http.StatusOK, rsp)
}

Step 4: 重新啟動服務器並測試

最後,我們返回 response 對象而不是 user。完成了!

現在讓我們重新啟動服務器。然後回到 Postman,將用戶名和電子郵件更新為新的值,然後發送請求。

現在它成功了。而且現在響應主體中再也沒有 hashed_password 字段了。完美!

透過這些改進,我們提高了 API 的安全性,並保護了用戶的敏感信息不被外洩。

{
    "username": "quang32",
    "full_name": "Quang Pham",
    "email": "quang32@email.com",
    "password": "secret"
}

{
    "username": "quang32",
    "full_name": "Quang Pham",
    "email": "quang32@email.com",
    "password_changed_at": "0001-01-01T00:00:00Z",
    "created_at": "2023-09-20T08:42:27.401476Z"
}

https://ithelp.ithome.com.tw/upload/images/20231017/20121746ljRrDBin7z.png


上一篇
[Day 28] How to handle DB errors in Golang correctly
下一篇
[Day 30] How to write stronger unit tests with a custom go-mock matcher
系列文
Techschool Goalng Backend Master Class 的學習記錄31
圖片
  直播研討會
圖片
{{ item.channelVendor }} {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言